tool
extends Control

class_name MyGridMapEditor

var undo_redo: UndoRedo = null

var menu: MyGridMapEditorMenu = null

class Adaptor:
	var tile_ids: Array = []
	var tile_names: Array = []
	func get_name() -> String:
		return ""
	func is_valid(object: Spatial) -> bool:
		return false
	func get_tile_id(object: Spatial, position: Vector3) -> String:
		return ""
	func set_tile_by_id(object: Spatial, position: Vector3, tile_id: String) -> void:
		pass
	func refresh_tile_types(object: Spatial) -> void:
		pass
	func lookup(object: Spatial, tile_id: String):
		return null
	func create_instance(tile_type) -> Spatial:
		return null
	func get_used_cells(object: Spatial) -> Array:
		return []

var current_object: Spatial = null
var adaptor: Adaptor = null

var selection_inst: Spatial = null
var grid_inst: Spatial = null

var current_tile_id: String = ""
var current_tile_inst: Spatial = null

var hittest_inst: Spatial = null
# Vector3 -> Area
var hittest_objs: Dictionary = {}

var pending_added_hittest_objs: Array = []
var pending_deleted_hittest_objs: Array = []

onready var lblCursor: Label = $VBoxContainer/lblCursor
onready var txtSearch: LineEdit = $VBoxContainer/HBoxContainer/txtSearch
onready var lstTiles: ItemList = $VBoxContainer/lstTiles

var tile_indices: Array = []

func _commit_pending_hittest_objs():
	if hittest_inst != null:
		for obj in pending_added_hittest_objs:
			hittest_inst.add_child(obj)
	else:
		for obj in pending_added_hittest_objs:
			obj.queue_free()
	pending_added_hittest_objs.clear()

	for obj in pending_deleted_hittest_objs:
		obj.queue_free()
	pending_deleted_hittest_objs.clear()

func setup(p_menu: MyGridMapEditorMenu):
	menu = p_menu
	menu.connect("mode_changed", self, "_mode_changed")
	menu.connect("floor_changed", self, "_floor_changed")

func _mode_changed(value: int):
	if current_object != null:
		current_object.set_meta("_editor_mode_", value)

	selection_inst.hide()
	lblCursor.text = ""
	grid_inst.hide()
	if current_tile_inst != null:
		current_tile_inst.hide()

	match value:
		MyGridMapEditorMenu.MODE_EDIT_X_AXIS, MyGridMapEditorMenu.MODE_EDIT_Y_AXIS, \
		MyGridMapEditorMenu.MODE_EDIT_Z_AXIS:
			grid_inst.show()
			_floor_changed(menu.current_floor)

func _floor_changed(value: Vector3):
	if current_object != null:
		current_object.set_meta("_editor_floor_", value)

	selection_inst.hide()
	lblCursor.text = ""
	if current_tile_inst != null:
		current_tile_inst.hide()

	match menu.current_mode:
		MyGridMapEditorMenu.MODE_EDIT_X_AXIS:
			grid_inst.transform = Transform(Vector3(0, 0, 1), \
				Vector3(1, 0, 0), Vector3(0, 1, 0), Vector3(value.x, 0, 0))
		MyGridMapEditorMenu.MODE_EDIT_Y_AXIS:
			grid_inst.transform = Transform(Vector3(1, 0, 0), \
				Vector3(0, 1, 0), Vector3(0, 0, 1), Vector3(0, value.y, 0))
		MyGridMapEditorMenu.MODE_EDIT_Z_AXIS:
			grid_inst.transform = Transform(Vector3(0, 1, 0), \
				Vector3(0, 0, 1), Vector3(1, 0, 0), Vector3(0, 0, value.z))

func edit(object: Spatial):
	lstTiles.clear()
	if adaptor != null:
		adaptor.tile_ids.clear()
		adaptor.tile_names.clear()
	tile_indices.clear()

	if selection_inst != null:
		selection_inst.queue_free()
		selection_inst = null

	if grid_inst != null:
		grid_inst.queue_free()
		grid_inst = null

	if current_tile_inst != null:
		current_tile_inst.queue_free()
		current_tile_inst = null

	if hittest_inst != null:
		hittest_inst.queue_free()
		hittest_inst = null
	hittest_objs.clear()

	_commit_pending_hittest_objs()

	current_object = object
	if object == null:
		current_tile_id = ""
		return

	selection_inst = preload("res://DebugSelection.tscn").instance()
	object.add_child(selection_inst)

	grid_inst = preload("res://DebugGrid.tscn").instance()
	grid_inst.setup(50)
	object.add_child(grid_inst)

	hittest_inst = Spatial.new()
	object.add_child(hittest_inst)

	# get used cells and create hittest area
	if adaptor != null and adaptor.is_valid(object):
		var used_cells: Array = adaptor.get_used_cells(object)
		for v in used_cells:
			var area: Area = preload("res://DebugHitTestArea.tscn").instance()
			area.transform.origin = v
			hittest_objs[v] = area
			hittest_inst.add_child(area)

	# get saved tile type id
	current_tile_id = (object.get_meta("_editor_tile_") as String) \
		if object.has_meta("_editor_tile_") else ""

	# update list of tiles
	_on_cmdRefresh_pressed()

	# get saved mode and floor
	menu.current_mode = (object.get_meta("_editor_mode_") as int) \
		if object.has_meta("_editor_mode_") else 0
	menu.current_floor = (object.get_meta("_editor_floor_") as Vector3) \
		if object.has_meta("_editor_floor_") else Vector3(0, 0, 0)

	_mode_changed(menu.current_mode)

func _set_tile_by_id(position: Vector3, tile_id: String, force_update_hitest_obj: bool) -> void:
	if adaptor == null or not adaptor.is_valid(current_object):
		return

	var old_id: String = adaptor.get_tile_id(current_object, position)
	if old_id != tile_id:
		if undo_redo != null:
			undo_redo.create_action("%s Set Tile" % adaptor.get_name())
			undo_redo.add_do_method(self, "_set_tile_by_id_internal", position, tile_id, force_update_hitest_obj)
			undo_redo.add_undo_method(self, "_set_tile_by_id_internal", position, old_id, true)
			undo_redo.commit_action()
		else:
			_set_tile_by_id_internal(position, tile_id, force_update_hitest_obj)

# warning: no sanity check
func _set_tile_by_id_internal(position: Vector3, tile_id: String, force_update_hitest_obj: bool) -> void:
	# create or remove hittest area
	if tile_id.empty():
		if hittest_objs.has(position):
			if force_update_hitest_obj:
				hittest_objs[position].queue_free()
			else:
				pending_deleted_hittest_objs.push_back(hittest_objs[position])
			hittest_objs.erase(position)
	else:
		if hittest_inst != null and not hittest_objs.has(position):
			var area: Area = preload("res://DebugHitTestArea.tscn").instance()
			area.transform.origin = position
			hittest_objs[position] = area
			if force_update_hitest_obj:
				hittest_inst.add_child(area)
			else:
				pending_added_hittest_objs.push_back(area)

	adaptor.set_tile_by_id(current_object, position, tile_id)

func forward_spatial_gui_input(camera: Camera, event: InputEvent) -> bool:
	if menu.current_mode == 0:
		return false

	if not event is InputEventMouse:
		return false

	var point: Vector2 = event.position
	var button_mask: int = event.button_mask

	if selection_inst != null:
		selection_inst.hide()
	if current_tile_inst != null:
		current_tile_inst.hide()
	lblCursor.text = ""

	if adaptor == null or not adaptor.is_valid(current_object):
		return false

	if event is InputEventMouseButton and event.pressed == false:
		_commit_pending_hittest_objs()
		return true

	var selected_cell: Vector3 = Vector3(0, 0, 0)

	var local_xform: Transform = current_object.global_transform.affine_inverse()

	if menu.current_mode > MyGridMapEditorMenu.MODE_EDIT_Z_AXIS:
		var from: Vector3 = camera.project_ray_origin(point)
		var normal: Vector3 = camera.project_ray_normal(point)
		var planes: Array = camera.get_frustum()

		var pdss: PhysicsDirectSpaceState = hittest_inst.get_world().direct_space_state
		var ret: Dictionary = pdss.intersect_ray(from, from + normal * camera.far, \
			[], 0x2, false, true)

		if ret.empty():
			return true

		var inters: Vector3 = ret.get("position")
		var collider: Spatial = ret.get("collider")

		# Make sure the intersection is inside the frustum planes, to avoid
		# painting on invisible regions.
		for pl in planes:
			if (pl as Plane).is_point_over(inters):
				return true

		match menu.current_mode:
			MyGridMapEditorMenu.MODE_ADD_AT_CURSOR:
				var inters_normal: Vector3 = local_xform.basis.xform(ret.get("normal")).normalized()
				if abs(inters_normal.x) > 0.5:
					inters_normal = Vector3(1 if inters_normal.x > 0 else -1, 0, 0)
				elif abs(inters_normal.y) > 0.5:
					inters_normal = Vector3(0, 1 if inters_normal.y > 0 else -1, 0)
				else:
					inters_normal = Vector3(0, 0, 1 if inters_normal.z > 0 else -1)
				inters = collider.transform.origin + inters_normal
			MyGridMapEditorMenu.MODE_EDIT_AT_CURSOR:
				inters = collider.transform.origin
			_:
				return true

		selected_cell = Vector3(round(inters.x), round(inters.y), round(inters.z))

	else:
		var from: Vector3 = local_xform.xform(camera.project_ray_origin(point))
		var normal: Vector3 = local_xform.basis.xform(camera.project_ray_normal(point)).normalized()
		var planes: Array = camera.get_frustum()

		var p: Plane
		match menu.current_mode:
			MyGridMapEditorMenu.MODE_EDIT_X_AXIS:
				p.normal = Vector3(1, 0, 0)
				p.d = menu.current_floor.x
			MyGridMapEditorMenu.MODE_EDIT_Y_AXIS:
				p.normal = Vector3(0, 1, 0)
				p.d = menu.current_floor.y
			MyGridMapEditorMenu.MODE_EDIT_Z_AXIS:
				p.normal = Vector3(0, 0, 1)
				p.d = menu.current_floor.z
			_:
				return true

		var inters = p.intersects_segment(from, from + normal * camera.far)
		if inters == null:
			return true

		# Make sure the intersection is inside the frustum planes, to avoid
		# painting on invisible regions.
		for pl in planes:
			var fp: Plane = local_xform.xform(pl)
			if fp.is_point_over(inters):
				return true

		selected_cell = Vector3(floor(inters.x), floor(inters.y), floor(inters.z))
		match menu.current_mode:
			MyGridMapEditorMenu.MODE_EDIT_X_AXIS:
				selected_cell.x = menu.current_floor.x
			MyGridMapEditorMenu.MODE_EDIT_Y_AXIS:
				selected_cell.y = menu.current_floor.y
			MyGridMapEditorMenu.MODE_EDIT_Z_AXIS:
				selected_cell.z = menu.current_floor.z

	selection_inst.show()
	selection_inst.setup(AABB(selected_cell, Vector3(1, 1, 1)))
	lblCursor.text = "%s %s" % [str(selected_cell), \
		adaptor.get_tile_id(current_object, selected_cell)]

	if current_tile_inst != null:
		current_tile_inst.show()
		current_tile_inst.transform.origin = selected_cell

	match button_mask:
		BUTTON_LEFT:
			_set_tile_by_id(selected_cell, current_tile_id, false)
		BUTTON_RIGHT:
			_set_tile_by_id(selected_cell, "", false)

	return true

func _on_cmdRefresh_pressed():
	tile_indices.clear()

	if adaptor != null:
		adaptor.refresh_tile_types(current_object)
		create_current_tile_inst()

	_on_txtSearch_text_changed(txtSearch.text)

func _on_txtSearch_text_changed(new_text):
	tile_indices.clear()
	lstTiles.clear()

	if adaptor != null:
		var s: String = new_text.to_lower()
		for i in range(adaptor.tile_names.size()):
			if s.empty() or adaptor.tile_names[i].to_lower().find(s) >= 0:
				tile_indices.push_back(i)
				lstTiles.add_item(adaptor.tile_names[i])

	for i in range(tile_indices.size()):
		if current_tile_id == adaptor.tile_ids[tile_indices[i]]:
			lstTiles.select(i)
			break

func _on_lstTiles_item_selected(index):
	current_tile_id = ""
	if current_tile_inst != null:
		current_tile_inst.queue_free()
		current_tile_inst = null

	if adaptor == null or not adaptor.is_valid(current_object):
		return

	if index >= 0 and index < tile_indices.size():
		current_tile_id = adaptor.tile_ids[tile_indices[index]]

	if current_object != null:
		current_object.set_meta("_editor_tile_", current_tile_id)

	create_current_tile_inst()

func create_current_tile_inst():
	if current_tile_inst != null:
		current_tile_inst.queue_free()
		current_tile_inst = null

	if adaptor == null or not adaptor.is_valid(current_object):
		return

	var current_tile_type = adaptor.lookup(current_object, current_tile_id)
	if current_tile_type == null:
		current_tile_id = ""
	else:
		var inst: Spatial = adaptor.create_instance(current_tile_type)
		if inst:
			current_tile_inst = inst
			current_object.add_child(inst)
			inst.hide()
